幹斷賽了...不過我還是會把 30 天寫完,因為我自己也想把這些東西做出來,加上如果我沒親手寫出來,其實很快就會忘記。
昨天我們用 IVF 和 HNSW 把檢索速度拉起來了,而接下來的問題是我們找資料的方式從頭到尾只有一種,就是把問題丟進 Embedding 模型變成向量,再去比對誰跟它最像,這招對付有實際含意的白話文能有很高的效能,但對於代號則一點方法都沒有。
所以今天我們要做一個經典的關鍵字檢索 BM25,再用 RRF 把兩邊的結果合在一起,湊成一套混合檢索。
這篇的完整程式碼一樣會放在 GitHub repo:https://github.com/AUSTIN2526/30-days-ironman-agent,可以直接下載下來執行。
前幾天我們的員工手冊都是白話文寫的,所以向量檢索幾乎沒什麼失手的機會,但公司裡的文件不可能這麼佛心,隨便翻都是表單編號、錯誤碼、軟體版本這種東西。所以今天我先把手冊補上幾個章節,塞進 HR-017、ERR-5012、v4.2 這類字串,讓它更像我們真的要處理的文件。
而問題就出在這些字串上 Embedding 模型是靠語意在認字的,但代號哪來的語意可言?HR-017 跟 HR-018 在向量空間裡幾乎黏在一起,v4.2 和 v4.1 也是。使用者問 HR-017 是什麼表單,模型只覺得你在問某張表單,至於是哪一張它其實沒什麼概念。這種時候我們要的根本不是語意相近,而是一個字都不能差的比對,這剛好就是關鍵字檢索的主場。

當然反過來也有一樣的問題,使用者問的是我想在家上班,手冊上寫的卻是遠端工作,兩句話連一個字都沒重疊,只會比對字面的方法就會出現問題,所以這兩種檢索從來不是誰要取代誰,而是剛好能夠互補。
BM25 在關鍵字檢索領域非常強大,長期以來一直是資訊檢索的經典方法。而它的計分方式其實可以拆成三個核心概念,基本上你只要搞懂這三件事,就等於掌握了 BM25 的核心,不過實際上也很簡單它可以理解成TF-IDF 的概念,再加上詞頻飽和與文件長度正規化。
所以他的第一個重點是 TF(Term Frequency),一個詞在文件裡出現越多次,代表這份文件越可能和它有關,但這個關係不是線性的,出現 10 次,不代表就比出現 5 次相關兩倍。所以 BM25 會用類似 f / (f + k1) 的形式,讓詞頻帶來的分數逐漸飽和,k1 通常設在 1.2~2.0 左右,而該作法就是要達成一個詞出現幾次很重要,但出現到一定程度後,再多出現也沒那麼值錢。

第二個是 IDF(Inverse Document Frequency),滿街都是的詞不值錢,像手冊裡的「公司」、「員工」、「申請」這些詞到處都有,自然很難用來區分文件,所以分數會被壓低。但是如果 ERR-5012 整份手冊只出現一次,它的 IDF 就會非常高。當使用者的問題剛好包含這個詞時,包含它的 Chunk 很容易直接排到前面。
第三個是 文件長度正規化,因為文件越長,本來就越容易順便出現某個關鍵字,如果完全不考慮長度,長文章可能只靠字多就取得較高分數,因此 BM25 會透過 b 參數(通常設為 0.75)對文件長度進行調整,讓長文件不會單純因為內容比較多,就在檢索中取得不公平的優勢。
不過在算 BM25 之前,中文還卡了一關,因為它不像英文一樣有空格,我們得先決定一個詞從哪裡開始、到哪裡結束,所以實務上通常會拿 jieba 這類套件來斷詞,但今天我想用更單純的方式,直接把中文切成 bigram,也就是每兩個相鄰的字算一個詞:
def tokenize(text: str) -> list[str]:
"""中文用 bigram 切,英文和數字保留成完整的詞。"""
text = text.lower()
tokens = []
for piece in re.findall(r"[a-z0-9]+|[一-鿿]+", text):
if piece[0].isascii():
tokens.append(piece) # 像 erp、hr、017、3300 這種就整個留著
else:
tokens.append(piece) # 單字本身也留一份,短詞才搜得到
tokens += [piece[i:i + 2] for i in range(len(piece) - 1)]
return tokens
拿差旅申請單這五個字來說,切出來就是差旅、旅申、申請、請單。

你可能會覺得旅申這種東西根本不算詞,看起來笨笨的,但它有兩個好處,一是不用詞典、不用裝套件,二是詞典裡沒有的新詞它照樣切得出來,而公司內部文件最愛出現的偏偏就是這種自己發明的詞,最後斷詞搞定之後,BM25 本體其實沒幾行。建索引的部分就是統計詞頻,然後把每個詞的 IDF 先算好:
class BM25:
def __init__(self, docs: list[str], k1: float = 1.5, b: float = 0.75):
self.k1, self.b = k1, b
self.docs = docs
self.doc_tokens = [tokenize(d) for d in docs]
self.doc_len = [len(t) for t in self.doc_tokens]
self.avgdl = sum(self.doc_len) / len(docs)
self.tf = [Counter(t) for t in self.doc_tokens]
df = Counter()
for tokens in self.doc_tokens:
df.update(set(tokens))
n = len(docs)
# 出現在越多文件裡的詞越不值錢,這就是 BM25 版本的 IDF
self.idf = {w: math.log(1 + (n - c + 0.5) / (c + 0.5)) for w, c in df.items()}
查詢的時候就是把問題也切成 token,再一個一個累加它在每份文件上的得分:
def score(self, query: str) -> list[float]:
q_tokens = tokenize(query)
scores = []
for i in range(len(self.docs)):
s = 0.0
for w in q_tokens:
f = self.tf[i].get(w, 0)
if not f:
continue
# 詞頻越高分數越高,但會飽和;文件越長則扣越多分
norm = f + self.k1 * (1 - self.b + self.b * self.doc_len[i] / self.avgdl)
s += self.idf[w] * f * (self.k1 + 1) / norm
scores.append(s)
return scores
這裡最爽的地方是從頭到尾沒有用到任何模型,不用 GPU、不用下載權重,建索引的速度比 Embedding 快上好幾個數量級,所以很多系統就算已經上了向量檢索,也還是會順手把 BM25 留著。
而當我們把今天的資料切成 13 個 chunk,再用 21 個問題測一輪,BM25 的成績是 Hit@1 和 Hit@3 都 86%,而翻車的剛好就是這三題:
爸爸住院我想請假陪他,公司有這種假嗎? 手冊寫的是「配偶、父母或子女需要親自照顧」
我想在家上班,需要先做什麼? 手冊寫的是「遠端工作」
電腦開不了機要找誰處理? 手冊寫的是「設備故障或無法開機」
這三題的共通點超級明顯,問題跟原文完全沒有共用的字,所以 BM25 的公式再怎麼調都沒救,這就是該換語意檢索上場的時候了。

既然兩邊各有各的強項,那最直覺的想法就是兩個都跑,再把結果合起來,不過這裡有個很多人會踩的雷,就是兩邊的分數根本不在同一個世界,向量檢索的相似度乖乖待在 0 到 1 之間,BM25 的分數卻可能是 3 分,也可能是 14 分,而且沒有上限,你直接加下去,等於整張排名都被 BM25 綁架。
比較好的做法是 RRF(Reciprocal Rank Fusion),它乾脆連分數都不看只看名次,每個 chunk 在一份排名裡拿的分數是 1 / (k + 名次),k 通常取 60,最後把它在兩份排名裡的分數加起來就是結果,這樣兩邊都排在前面的 chunk 會被推到最前面,只有單邊看好的就往後退,而 0.81 還是 14.2 這種尺度差異完全影響不到它。
def rrf(rankings: list[list[int]], k: int = 60) -> list[int]:
"""Reciprocal Rank Fusion:只看名次不看分數,所以兩邊的分數尺度不用對齊。"""
scores = {}
for ranking in rankings:
for rank, idx in enumerate(ranking):
scores[idx] = scores.get(idx, 0.0) + 1 / (k + rank + 1)
return sorted(scores, key=lambda i: -scores[i])
至於 k 在幹嘛,它其實是在壓平名次之間的落差。k 設很小的話,第一名和第二名的分數會差很多,合併結果就容易被其中一邊帶著走;k 設大則是讓前幾名越靠越近,變成比誰在兩邊都夠穩。60 是原始論文用的數字,實務上大家也都直接沿用,真的想調再說。
最後把幾種做法丟到同一份手冊、同一組問題上比較,讓變因只剩下檢索方式:
methods = {
"向量檢索": dense_rank,
"BM25": sparse_rank,
"混合檢索(RRF)": lambda q: rrf([dense_rank(q), sparse_rank(q)]),
}
最後執行前幾天寫的compare.py,但看到這個結果我第一個反應是傻眼,因為混合檢索不但沒有變強,還被 BM25 整個拖下水,連向量本來答對的兩題都不見了。
| 方法 | Hit@1 | Hit@3 | 前三名沒找到答案的題目 |
|---|---|---|---|
| 向量檢索 | 86% | 95% | 我想在家上班 |
| BM25 | 86% | 86% | 爸爸住院請假、我想在家上班、電腦開不了機 |
| 混合檢索(RRF) | 86% | 86% | 爸爸住院請假、我想在家上班、電腦開不了機 |
而這是因為問題出在我把完整排名丟進 RRF,我們只有 13 個 chunk,兩邊的排名都是 13 筆,等於 BM25 對每一塊都有跟向量一模一樣的投票權,連它根本看不懂的題目也照投不誤。

像是爸爸住院那題,BM25 的前三名全都只是剛好共用了請假兩個字,而真正的正解被它排到第 11 名。1 / (60 + 11) 這張票一加下去,向量原本排在前面的正解就被那些兩邊都有一點票的 chunk 擠掉了。
所以在資料較小的時候,我們可以採用每一邊只派前幾名出來投票,沒進候選池的完全不給分,這樣 BM25 遇到不懂的題目就會自動棄權,不會再扯後腿:
def rrf(rankings, k: int = 60, pool: int | None = None):
scores = {}
for ranking in rankings:
for rank, idx in enumerate(ranking[:pool] if pool else ranking):
scores[idx] = scores.get(idx, 0.0) + 1 / (k + rank + 1)
return sorted(scores, key=lambda i: -scores[i])
加上候選池之後再測一次:
| 方法 | Hit@1 | Hit@3 | 前三名沒找到答案的題目 |
|---|---|---|---|
| 混合(全部排名) | 86% | 86% | 爸爸住院請假、我想在家上班、電腦開不了機 |
| 混合(各取前 5) | 86% | 90% | 爸爸住院請假、我想在家上班 |
| 混合(各取前 3) | 86% | 95% | 我想在家上班 |
候選池收得越緊,成績就越靠近向量檢索,到前 3 名時已經完全追平,原因也不難理解,我們的知識庫只有 13 塊,每一塊講的事情差很多,代號題就算只靠語意也猜得到,像是問 HR-017 是什麼表單,向量模型光看到表單兩個字就找到差旅那塊了,BM25 根本沒有表現的機會,真正會分出勝負的是那種有上百塊、而且主題彼此很接近的知識庫,例如整份產品文件裡有十幾個型號、每個型號的說明又長得很像,那時候少一個精確比對就是直接翻車。
所以今天這個實驗真正的收穫不是混合檢索比較強,而是要知道RRF 的候選池沒設好,反而會被比較弱的那一邊拉下來,以及任何架構上的加法都要用自己的資料量過,不能憑感覺。
也因為這樣混合檢索不是無腦使用的方案它等於要養兩套索引,查詢時要跑兩次再合併,延遲跟記憶體都會比單用一種高。所以如果你的文件裡根本沒有代號、型號這種東西,使用者問的也都是白話問題,那單靠向量檢索就夠了,真的不用為了架構看起來完整就硬加。
今天我們把就算混合檢索的正確答案撈進前三名了,但因為 top_k=3 該湊滿還是會湊滿,剩下那兩筆不相關的東西照樣會被塞進 Prompt 裡,所以明天要來處理最後一段,也就是 Reranking,看看 Cross-Encoder 這種把問題和文件綁在一起丟進模型的做法,為什麼可以排得比向量檢索準,以及它到底慢在哪,那我們明天再見!